iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0

主辦單位傳來一張活動海報,時間、地點散在各個角落,LINE 小幫手要怎麼查?今天用 Gemini 看圖,把視覺資訊整理成固定欄位,經過人工核對後接回昨天的搜尋工具。像是「活動 7 點半開始、那幾點集合」這種差別,模型分得清楚嗎?我們拿彰化縣政府「卦山大縱走」的真實海報,換兩種提示來看看。

Day 6 turns a real event poster into searchable LOCAL data using Gemini image understanding and structured outputs. We compare two prompts on the same image, inspect field-level quotations, review the extracted values, and reuse Day 5’s Python search tool. The example separates event details from meeting instructions and preserves missing information for later clarification.

https://ithelp.ithome.com.tw/upload/images/20260920/20120682ZgGaZWoBb0.png
圖 1:左側為彰化縣政府「卦山大縱走」活動海報,右側對照一般提示(產生 Schema Error)與欄位提示(成功擷取 8 場活動與引句)。回到原圖核對標籤,就能確認資訊是否放進了正確欄位。

本次留下的成果 紀錄
海報來源 https://www.chcg.gov.tw/ch2/newsdetail.aspx?bull_id=436513
同圖提示比較 一般提示:SCHEMA_ERROR;欄位提示:EXTRACTED
人工核對後的活動/場次 8 筆(整理出卦山大縱走全部 8 場步道)
沿用 Day 5 工具查詢 查到 8 筆;查詢階段 API 呼叫 0 次(本機 Python 工具)

一、昨天會查,今天幫它準備資料

想像主辦單位傳來一張海報,活動名稱很醒目,日期在中間,集合地點卻縮在右下角。對人來說,在手機上放大一點就看得懂;要讓 LOCAL 回答問題,還得把這些資訊放進程式能讀的欄位。

昨天的 LOCAL 已經能透過 search_local_events 查四筆活動,還把集合資訊送回 LINE。今天換個做法:海報交給 Gemini 先整理,我們對照原圖核對,再把資料交回同一個搜尋工具。

海報負責吸引人,資料負責讓人查。總不能主辦每改一次海報,我們就重新抄寫一次時間地點吧!中間這段「把視覺資訊搬到對的欄位」,今天交給 Gemini 先幫忙。

完整流程如下:

活動海報 → Gemini 圖片理解 → 結構化欄位 → 對照原圖核對 → 活動目錄 → Day 5 搜尋工具。

這次從管理資料的一端補上能力,既有 LINE 入口繼續保留。讀者先在電腦上跑通資料流程,就能看見同一張海報如何變成可查詢的內容。

本次提示對照與執行紀錄

看成果時,我把原圖、一般提示、欄位提示與人工採用值並排。兩組提示在同一次測試中各執行一次:

組別 執行狀態 擷取結果 呼叫耗時 Token 總量
一般提示 SCHEMA_ERROR 欄位驗證未通過(已收到文字,但日期格式驗證失敗,原始回覆保留於紀錄) 6.760 秒 4,124
欄位提示 EXTRACTED 成功擷取 8 場活動與原文引句,作為本次人工核對起點 7.616 秒 4,146

欄位提示擷取與人工核對結果

這次彰化縣政府的「卦山大縱走 彰化兜兜圈」海報涵蓋了 8 場步道活動,模型整理出每一條步道路線與對應的鄉鎮市區:

場次名稱 鄉鎮市區 場地/步道 圖片月日(quote) 活動時段 集合時間/地點 全程輪椅通行
卦山大縱走 彰化兜兜圈 花壇 大嶺巷步道 09.19 (null) 07:30~11:00 null / null null
卦山大縱走 彰化兜兜圈 二水 登廟步道 09.26 (null) 07:30~11:00 null / null null
卦山大縱走 彰化兜兜圈 芬園 挑水古道 10.10 (null) 07:30~11:00 null / null null
卦山大縱走 彰化兜兜圈 彰化市 桃源里森林步道 10.24 (null) 07:30~11:00 null / null null
卦山大縱走 彰化兜兜圈 彰化市 大佛環山步道 10.31 (null) 07:30~11:00 null / null null
卦山大縱走 彰化兜兜圈 田中 麒麟山步道 11.07 (null) 07:30~11:00 null / null null
卦山大縱走 彰化兜兜圈 員林 藤山步道 11.14 (null) 07:30~11:00 null / null null
卦山大縱走 彰化兜兜圈 社頭 十八彎及中央嶺步道 11.21 (null) 07:30~11:00 null / null null

我逐項對照原圖後,採用這組八場資料,這次沒有修改欄位。八場的名稱、鄉鎮、步道與時段都和原圖一致;年份、集合時間/地點與通行條件海報本來就沒印,所以沒有可改的欄位。圖片只寫月日,完整日期仍缺年份;接下來會補上對應公告的依據。

我的判讀:

海報通常只寫活動時段與步道名稱,未特別註記集合點與無障礙,這跟真實世界完全一致。一般提示因為海報只印月日(如 09.19)缺少西元年份,模型直接填寫格式不符的字串而觸發本機 Schema 格式錯誤;欄位提示則能正確辨識缺漏,把值保留為 null 並在 quote 留下原文字串,更把卦山大縱走全部 8 場步道完整整理出來。

執行條件 本次紀錄
指定模型/思考等級 gemini-3.8-flash/LOW
回傳模型版本(一般/欄位) gemini-3.8-flash/gemini-3.8-flash
Google GenAI SDK/Pydantic 2.23.0/2.13.5
本次擷取 API 嘗試 2 次
呼叫耗時:一般/欄位 6.760/7.616 秒
token 總量:一般/欄位 4,124/4,146
圖片 SHA-256 47407ae3de5c34f36f77c11f31a98a1537033d70909af923517f5c101b7b86da
離線/SDK 檢查 43 項通過(修訂版新增回歸測試至 46 項全數通過)/PASS
原始來源更新時間 null
人工核對時間 2026-09-19T13:06:10.999670+00:00

二、先拿一張海報來試

選一張你可使用、字跡清楚的 PNG、JPEG 或 WEBP 海報,第一輪以單一活動或系列場次為主;有明確日期、活動時段和地點,會比較容易核對。自己辦過的講座或工作坊海報也很適合,熟悉內容更容易發現差異。海報裡若有集合或報到資訊,更能看出這次欄位設計的用處。

從 Repo 根目錄操作。沿用 Day 5 已建立的 Python 環境,先檢查本篇程式:

examples/day05/.venv/bin/python examples/day06/verify.py --sdk

這會檢查資料格式、欄位核對、Day 5 搜尋工具的介接,以及 SDK 的設定型別。需要另外建立環境的讀者,可依 examples/day06/requirements.txt 安裝;我們使用的是 google-genai==2.23.0。

接著使用既有 Gemini 金鑰。圖片會送到 Gemini API,先選好適合用於這次實驗的素材,並確認帳戶用量設定。在環境中設定 GEMINI_API_KEY 後執行:

examples/day05/.venv/bin/python examples/day06/run.py --live \
  --image /你的海報路徑/poster.jpg \
  --source-ref "海報原始網址或來源名稱"

同一張圖片會送出兩次:一次用一般提示,一次用欄位提示。兩組共用模型、schema 與生成設定,差別放在提示文字。

完成後,打開程式印出的 REPORT.html。左側是輸入原圖,右側是兩組整理出的欄位。先在下方選一組作為核對起點,再修正內容、留下心得,按「儲存 review.json」。畫面把模型摘錄的引句也列出來,讓我們回原圖找出它對應的文字。

三、給模型看的,是圖片加上一份欄位表

Gemini 的圖片理解可以同時處理文字提示與圖片。這裡沿用前幾篇的 GenerateContent 呼叫,把圖片的原始位元組放進 Part。[1]

以下節錄 run.py 的核心,image_bytes、prompt 與 client 由完整程式建立:

response = client.models.generate_content(
    model="gemini-3.8-flash",
    contents=[
        types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg"),
        prompt,
    ],
    config=types.GenerateContentConfig(
        response_mime_type="application/json",
        response_json_schema=Extraction.model_json_schema(),
        max_output_tokens=8192,
        thinking_config=types.ThinkingConfig(
            thinking_level=types.ThinkingLevel.LOW
        ),
    ),
)

這幾行替我們做兩件事:把圖片交給模型看,也把輸出欄位先約定好。 檔案實際 MIME 類型由程式判讀;上面以 JPEG 為例。

這次的主角是 Gemini 圖片理解與 Structured Outputs。後者把回答整理成指定的 JSON 結構;Python 再用 Pydantic 檢查型別與欄位關係。[2][5] 範例沿用 Day 5 的 SDK 2.23.0 與 GenerateContent 寫法,重現時以本文及該版本的程式為一組。

Day 5 的 Function Calling 是模型提出「幫我查」,程式執行工具;今天的 Structured Outputs 則是請模型「把你讀到的資訊,填進這些欄位」。一個負責提出操作,一個負責整理資料,剛好接成 LOCAL 的兩端。[2]

思考設定:LOW 與輸出上限

本次兩組都使用 LOW,並把 max_output_tokens 設為 8,192。這是這次實驗的生成上限,思考與回覆 token 都計入其中;實際用了多少,則看回應的用量紀錄。[3]

今天比較的是提示寫法,所以圖片、模型、schema 與思考設定保持相同。至於提高思考等級是否值得,我會在 Day 20 的模型設定與成本篇,用同一組任務實際比較。

這裡透過 GenerateContent 的 thinking_level 設定思考等級。Gemini 2.5 在這條 API 路徑使用的是 thinking_budget;本例沿用目前模型的設定。[3]

欄位數量上限的實作觀察

這次我把場次上限留在本機的 Pydantic validator:Gemini 先依欄位結構整理資料,Python 收到後再檢查是否超過十筆。[2][4]

這個改法把「請模型產生哪些欄位」與「應用程式接受多少筆」分開。下面這段 validator 取自修訂版程式(實驗執行的程式版本為 bb0f1f1,程式雜湊與 verification.json 一致):收到十一筆時,本機會拒絕採用,原始回覆仍留下來供檢查。

# events 的十筆上限由收到回覆後的 event_limit 檢查。
# 此欄位未透過 Field(max_length=...) 輸出 maxItems;
# description 提供模型說明,不代表 API 的通用場次上限。
@field_validator("events")
@classmethod
def event_limit(cls, v: list[PosterEvent]) -> list[PosterEvent]:
    if len(v) > 10:
        raise ValueError("活動場次最多十筆。")
    return v

這幾行做的是收到資料後的本機檢查,並把有效資料交回下一步。description 裡的「最多十筆」是提示,真正的數量檢查在這個函式。[4]

四、活動時間和集合時間,值得各有一格

我們採用八個欄位:活動名稱、日期、鄉鎮市區、活動時間、活動場地、集合時間、集合地點,以及全程輪椅通行條件。

其中最容易放錯的,是兩組看起來很像的資訊:

海報的意思 資料欄位
活動從幾點開始、持續到幾點 time
參加者幾點要先集合或報到 meeting_time
活動在哪個場地進行 venue
參加者先到哪裡碰面 meeting_point

假設一場走讀九點半開始,九點先在車站集合,兩個時間都讀對、卻放錯欄位,參加者還是可能遲到。這是欄位設計要處理的問題:除了辨認字,也要看懂標籤與內容的關係。

每個欄位都帶著三樣東西:值、圖片原文、辨讀狀態。文字欄位的結構如下,完整檢查見 schema.py:

class TextField(BaseModel):
    value: str | None
    quote: str | None
    status: Literal["stated", "not_shown", "unclear"]

這幾行讓欄位既能放資料,也能標示「已讀到」「來源缺漏」「待核對」三種情況。以本次第一場花壇大嶺巷步道為例,模型回傳的三個欄位真實值如下:

欄位 value quote status 解讀
活動日期 null 09.19 unclear 看見月日,完整日期還缺年份。
活動時間 07:30~11:00 週六 07:30~11:00 stated 圖片明寫的活動時段。
全程輪椅通行 null null not_shown 圖片沒有這項資訊。

同樣是 null,有的是資訊只寫了一半,有的是根本沒寫。把原文與狀態一起留下來,下一步就知道要補什麼。

quote 幫我找到圖片上的依據;我再回到原圖,確認這段文字說的是活動開始,還是集合報到。這也是為什麼我把 time 和 meeting_time 分成兩個欄位。

海報上的月日保存在 quote,完整日期先保留 null。本篇先按活動名稱查詢;下一篇再用對應的官方公告補年份,把新增依據一起記下來,讓日期搜尋也用得起來。

五、同一張圖,換兩種說法

一般提示很直接:「請閱讀這張活動海報,依提供的欄位結構整理活動資料。」欄位提示則多說明了要怎麼分辨:活動日與報名截止日各有用途;集合資訊獨立填寫;年份缺漏時保留月日原文;通行條件看完整路線的描述。

兩組共用 schema,因此比較的重點是:把欄位的意思說清楚,是否讓模型更容易把資訊放對位置?

這次一般提示那組已取得文字,但在本機的欄位檢查停下來。SCHEMA_ERROR 告訴我們要回頭看哪個欄位;它和請求送出時的 HTTP 400,發生在不同階段。本機驗證在八筆 events 都回報 value_error;對照原始回覆,date.value 填了 "09.19",而 schema.py 的日期規則只接受完整西元日期。欄位提示組則依說明將其值填為 null 並將 "09.19" 保留在 quote 且標示 unclear,順利通過本機驗證。

小型對照的用途,是找到下一次值得改進的問題。每組這次各執行一次,模型使用的條件與用量留在報告中。

六、JSON 通過了,集合時間卻放錯了?

我們另外做一個很小的本機反例:建立「活動 09:30、集合 09:00」的測試資料,再刻意把集合時間改成 09:30。

這是 counterexample.py 中手工建立的測試情境。Pydantic 仍能接受這份資料,因為字串、欄位、引句與狀態都完整;但與預期的集合時間一比,就會看見差異。

格式檢查回答「這份資料長得對不對」,來源核對回答「這個值放得對不對」。 Google 的 Structured Outputs 文件也要求應用程式另外檢查值的語意。[2]

另一個值得看的欄位是 accessibility。海報明寫全程適合輪椅,才有依據填 true;明寫路線有不適合通行的條件,才填 false;缺少相關資訊時,留下 null。只看到入口坡道或無障礙廁所,就把整條路線判成可通行,跳得太快了。

這樣,LOCAL 可以先把時間和地點提供給使用者,再針對缺少的通行資訊,建議向主辦確認。

七、核對完,交回昨天的搜尋工具

在 REPORT.html 看完原圖,從兩組中選定核對起點、修正必要欄位,留下修改原因與判讀心得,儲存 review.json。接著將核對結果匯出為新的活動目錄:

examples/day05/.venv/bin/python examples/day06/review.py \
  --run output/day06/這次的擷取資料夾 \
  --review-file /你的下載路徑/review.json

程式會印出 catalog.reviewed.json 的位置。用它進行查詢:

examples/day05/.venv/bin/python examples/day06/query.py \
  --catalog output/day06/這次的核對資料夾/catalog.reviewed.json \
  --keyword "海報上的活動名稱"

這幾行沿用 Day 5 的 make_search_tool 與搜尋函式,換成今天核對後的資料。這一步直接呼叫 Python 工具,查詢結果存成 query.json;圖片擷取與資料查詢的成果就接起來了。

Day 5 的教學載入器指定手寫合成目錄,所以 Day 6 用自己的入口載入 reviewed_poster_data,再重用搜尋邏輯。這裡還有一個欄位演進:前篇只有一個 time,今天另外加入 meeting_time 與 venue,把活動時段、集合時間和場地拆開。搜尋函式可以保留這些欄位,未來接回 Agent 回答時,也要讓提示明白各欄位的意思。

每筆資料的 source 由程式填入操作者提供的來源,圖片雜湊則指出本次使用哪一份檔案。來源沒有提供更新時間時,updated_at 保留 null;另外記錄的擷取與核對時間,描述的是我們整理資料的時間。

八、這次的取捨:整理一次,再讓工具反覆查

另一種做法,是每次有人問活動,就把整張海報交給模型重看。這次選擇先整理成欄位,再交給工具查詢:查詢端的資料比較一致,人工修正也有固定的位置,不必每次提問都重複消耗圖片辨識的 Token。 代價是多了一道整理與核對流程,主辦單位換海報時,資料也要跟著更新。

這種做法適合來源已經選定、會被重複查詢的活動資料。臨時拿一張圖問一個問題,直接看圖回答也很合理;要不要先整理,取決於資料接下來怎麼用。

今天的範圍收在三件事:圖片從本機提供、核對由人工確認、資料存成可搜尋的 JSON,不把未經審核的抽取結果直接推上線。大量來源、版本衝突與持續更新,接著往 Day 7 展開。

九、海報變資料,LOCAL 又接好了一段

Day 1 提到「模型輸出受約束資料,再由程式驗證」,今天先把這個設計用在海報整理。資料能被查詢之後,後面的 LINE 畫面與服務流程就有了共同依據。

今天我們把活動海報整理成可核對、可查詢的資料。下一篇接住讀者的新問題:主辦換了一張海報,LOCAL 要怎麼找出改動、補上來源,又決定查詢該用哪一版?

你手邊的活動海報,哪一項資訊最容易被放錯欄位:活動時間、集合位置,還是報名截止日?

程式與參考資料

本篇實驗執行的程式版本為 bb0f1f1,程式雜湊與 verification.json 一致;相關註解與回歸測試已同步更新於 LOCAL 專案 的 examples/day06/,重現說明在 docs/day06/README.md;搜尋工具承接 examples/day05/catalog.py。[6]

前篇:Day 5|ADK+受控查詢:第一個 Agent 與 Orchestration。下一篇:Day 7|看懂不等於可信:來源、版本與不知道。


上一篇
Day 5|ADK+受控查詢:第一個 Agent 與 Orchestration
下一篇
Day 7|看懂不等於可信:來源、版本與不知道
系列文
LOCAL:30 天打造 LINE × Google AI 地方服務 Agent 共 12 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
lin1015
iT邦新手 5 級 ‧ 2026-09-20 12:19:13

你把 value、quote、status 綁在每個欄位,再用原圖人工核對,讓格式正確和語意正確不會混在一起。若主辦更新同一張海報,你會用圖片雜湊直接建立新版,還是先比對欄位差異,只將變動項目送人工複核?

謝謝,你問到關鍵。兩者會分工:圖片雜湊只辨識「來源檔是否換版」;真正決定人工複核的是 value/quote/status 的欄位差異,沒變的欄位沿用既有核對紀錄。多場次還要先解決跨版本配對,因為目前的 id 綁著雜湊,不能直接拿陣列位置硬比。這一段我會在 Day 7 用同一張海報的兩個版本實際跑一次,敬請期待!

我要留言

立即登入留言